Next.js App Router에서 fetch를 axios처럼 쓰는 법
2025-08-01
들어가며
주로 사용하는 스택이 React에서 Next로 넘어가며 자주 사용해오던 axios를 사용하지 않게 되었는데요, Next.js 13 이상의 App Router 및 서버 컴포넌트에서는 fetch 사용이 표준화되어 있기 때문입니다. 그래서 axios에서 사용해오던 인터셉터, 자동 JSON 파싱 등의 편리한 기능을 Next에서는 어떻게 구현할 수 있을까 고민하게 되었습니다.
Next.js 13 이상 버전에서 App Router 및 서버 컴포넌트를 사용하면서, 기존에 즐겨 사용하던 axios 대신 fetch를 표준으로 사용하게 되었습니다. 그에 따라 axios에서 제공하던 편리한 기능들 (예를 들어 인터셉터, 자동 JSON 파싱 등)을 어떻게 fetch 환경에서도 구현할 수 있을지 고민하게 되었습니다.
예전에 토스 테스트에서 아래와 같은 형태로 Http 객체를 정의해 호출 로직을 일원화하는 접근을 본 적이 있는데요.
declare class Http {
get<T>(path: string, options?: Omit<HttpRequestOptions, 'json'>): Promise<T>;
post<T>(path: string, options?: HttpRequestOptions): Promise<T>;
patch<T>(path: string, options?: HttpRequestOptions): Promise<T>;
put<T>(path: string, options?: HttpRequestOptions): Promise<T>;
delete<T>(path: string, options?: HttpRequestOptions): Promise<T>;
}
이 방식에서 아이디어를 얻어, fetch 기반의 HTTP 모듈을 직접 구축해보기로 했습니다. 이 글에서는 해당 모듈을 설계하면서 고려한 공통 로직 일원화 및 편의성 확보에 대해 정리하려 합니다.
지금부터 할 것은 다음과 같습니다.
- 요청 메서드 통일
- 공통 로직 일원화 (ex. 헤더 옵션 처리)
- 쿼리 파라미터 자동 처리
- 응답 처리 통일
- 에러 핸들링 로직 분리
1번은 axios의 인스턴스 역할을 하고, 2,3,4,5번은 인터셉터 역할을 하겠네요.
http 객체 선언하기
먼저, 토스에서 사용했던 방식처럼 공통 http 객체를 정의했습니다. 메서드 구조는 axios와 거의 유사하게 맞췄기 때문에 익숙한 방식으로 사용할 수 있습니다.
// http.ts
import { fetchWrapper } from './fetch-wrapper';
export type HttpOptions = RequestInit & {
params?: Record<string, any>;
cache?: RequestCache;
next?: {
revalidate?: number;
tags?: string[];
};
};
export const http = {
get: <T = any>(url: string, options: HttpOptions = {}): Promise<T> =>
fetchWrapper<T>(url, { method: 'GET', ...options }),
post: <T = any>(url: string, data?: any, options: HttpOptions = {}): Promise<T> =>
fetchWrapper<T>(url, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
...options,
}),
put: <T = any>(url: string, data?: any, options: HttpOptions = {}): Promise<T> =>
fetchWrapper<T>(url, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
...options,
}),
patch: <T = any>(url: string, data?: any, options: HttpOptions = {}): Promise<T> =>
fetchWrapper<T>(url, {
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
...options,
}),
delete: <T = any>(url: string, options: HttpOptions = {}): Promise<T> =>
fetchWrapper<T>(url, { method: 'DELETE', ...options }),
};
fetchWrapper 구현하기
http 객체 자체는 간결하고 직관적입니다. 중요한 건 공통 로직을 처리할 fetchWrapper 함수입니다. 제가 진행 중인 프로젝트에서는 accessToken이 필요해서, 쿠키에서 accessToken을 꺼내는 로직이 들어가야 합니다. 그리고 받은 options들을 적절히 분리해서 fetch에 넘겨주는 작업도 필요합니다.
구현할 항목은 다음과 같습니다.
- headers:
- 기본적으로 Content-Type: application/json 설정
- 인증이 필요한 경우 Authorization: Bearer
추가
- params:
- 쿼리 파라미터를 자동으로 문자열로 변환해 URL에 붙이기
- restOptions:
- 위에서 분리한 나머지 옵션들 (method, body, cache, next 등)을 fetch에 전달
이 모든 처리를 fetchWrapper 하나로 통합해서, 사용하는 쪽에서는 신경 쓸 필요 없이 http.get(...), http.post(...) 만 호출하면 되도록 만들어 보겠습니다.
1. URL + 쿼리 파라미터 조합
제가 진행하는 프로젝트는 서버에 레거시가 남아 있어, 서버에서 전달받은 accessToken이 담긴 쿠키를 가져와 Authorization 옵션에 넣어야 합니다. 이 때문에 쿠키에서 토큰을 꺼내오는 로직이 포함됩니다.
// fetchWrapper.ts
export const fetchWrapper = async <T = any>(
url: string,
options: FetchWrapperOptions = {}
): Promise<T> => {
const cookieStore = await cookies();
const accessToken = cookieStore.get('accessToken');
const { headers, params, ...restOptions } = options;
const fullUrl = buildUrl(url, params);
// ...continue
};
위의 buildUrl 함수는 params를 URLSearchParams 형태로 직렬화하고 URL에 붙여주는 유틸입니다. 저는 아래와 같이 구현했습니다.
// util.ts
export const buildUrl = (url: string, params?: Record<string, any>): string => {
if (!params) return url;
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
if (Array.isArray(value)) {
value.forEach((v) => searchParams.append(key, v));
} else {
searchParams.append(key, value);
}
});
return `${url}?${searchParams.toString()}`;
};
2. fetch 호출 및 공통 처리
이제 fetch를 호출합니다. 여기서는 인증 토큰, content-type 헤더, 기타 options 등을 조합해서 요청을 구성합니다
// fetchWrapper.ts
export const fetchWrapper = async <T = any>(
url: string,
options: FetchWrapperOptions = {}
): Promise<T> => {
const cookieStore = await cookies();
const accessToken = cookieStore.get('accessToken');
const { headers, params, ...restOptions } = options;
const fullUrl = buildUrl(url, params);
try {
const response = await fetch(fullUrl, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken?.value}`,
...headers,
},
...restOptions,
});
return handleResponse<T>(response);
}
};
이렇게 하면 사용자는 요청 시 별도로 헤더를 설정하거나 토큰을 신경 쓰지 않아도 됩니다. 또한, 필요에 따라 headers 옵션으로 덮어쓸 수도 있어 유연합니다.
3. 응답 처리 - handleResponse
이전 코드를 보시면 handleResponse로 response를 감싸서 return하는 것을 볼 수 있습니다. 응답을 받아 처리하는 것도 하나의 관심사로 분리해서 handleResponse 함수로 감쌌습니다. fetch 특유의 verbose한 응답 처리 코드를 매번 작성할 필요 없이, 항상 동일한 방식으로 데이터를 다루는 것이 필요합니다.
다음을 고려하여 구현하였습니다.
- 실패 시
- JSON 응답이면 파싱해서 에러 메시지 포함
- 텍스트 응답도 시도
- 마지막엔 HttpError로 감싸서 throw
- 성공 시
- 응답의 Content-Type에 따라 json, text, 또는 원시 Response를 반환
코드는 다음과 같습니다.
const handleResponse = async <T>(response: Response): Promise<T> => {
if (!response.ok) {
const contentType = response.headers.get('content-type');
const errorData = contentType?.includes('application/json')
? await response.json().catch(() => null)
: await response.text().catch(() => null);
throw new HttpError(
errorData || `HTTP error! Status: ${response.status}`,
response.status,
errorData
);
}
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return response.json() as Promise<T>;
}
if (contentType?.includes('text/')) {
return response.text() as unknown as Promise<T>;
}
return response as unknown as Promise<T>;
};
이어서 catch 문에 에러 핸들링 로직을 추가하면 fetchWrapper는 완성입니다. 이미 HttpError로 처리된 에러라면 그대로 던지고, fetch 과정에서 발생한 네트워크 계열 오류라면 HttpError로 래핑하여 던집니다. 그 외에 예상치 못한 예외는 'Network error: Unable to connect to server' 메시지와 함께 HttpError로 래핑하여 처리를 했습니다.
// fetchWrapper.ts 전체 코드
export const fetchWrapper = async <T = any>(
url: string,
options: FetchWrapperOptions = {}
): Promise<T> => {
const cookie = await cookies();
const accessToken = cookie.get('accessToken');
const { headers, params, ...restOptions } = options;
const fullUrl = buildUrl(url, params);
try {
const response = await fetch(fullUrl, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken?.value}`,
...headers,
},
...restOptions,
});
return handleResponse<T>(response);
} catch (error) {
if (error instanceof HttpError) {
throw error;
} else if (error instanceof TypeError && error.message.includes('fetch')) {
throw new HttpError('Network error: Unable to connect to server', 0, {
originalError: error,
});
} else {
throw new HttpError('Unexpected error occurred', 0, { originalError: error });
}
}
};
이처럼 fetch 기반으로 공통된 로직을 처리할 수 있는 HTTP 유틸리티를 구성했습니다. axios와 구조가 비슷해서 axios를 사용하던 분들도 쉽게 사용할 수 있을 것 입니다.
나가며
axios 대신 fetch API를 사용하게 되면서, 공통 로직 처리를 위해 해당 유틸을 구현했습니다. 에러 처리 측면에서는 더 유연하게 대응할 수 있었던 것 같습니다.
현재까지는 이 유틸만으로 충분했습니다.
다만, 이 코드가 완성도 높은 예시는 아닐거라고 확신 생각합니다.
기록 차원에서 남기는 것이니, 보시는 분께서는 참고용으로만 봐주시길 바랍니다.